@wonseok-han/confluence-sync 1.2.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -2
- package/dist/anchors.js +27 -5
- package/dist/convert.js +17 -5
- package/dist/docs.js +2 -1
- package/dist/help.js +11 -3
- package/dist/mapping.js +2 -0
- package/dist/obsidian.js +17 -11
- package/dist/pull.js +90 -4
- package/dist/render.js +27 -0
- package/dist/sync.js +34 -22
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -100,8 +100,8 @@ confluence-sync --dry-run # 대상·상태(신규/변경/동일)
|
|
|
100
100
|
confluence-sync # 변경된 문서만 갱신 (+ 신규 생성)
|
|
101
101
|
|
|
102
102
|
confluence-sync --base /abs/path/docs # 동기화 루트 지정 (env 미설정 시 필수)
|
|
103
|
-
confluence-sync guide/ # 지정 폴더만
|
|
104
|
-
confluence-sync guide/setup.md # 단일 문서만
|
|
103
|
+
confluence-sync guide/ # 지정 폴더만
|
|
104
|
+
confluence-sync guide/setup.md # 단일 문서만 (상위 README 는 발행되지 않음)
|
|
105
105
|
confluence-sync --force # 변경 감지 무시, 전체 강제 갱신
|
|
106
106
|
confluence-sync --verify # 변경 없는 문서도 페이지 존재 확인
|
|
107
107
|
confluence-sync --rebuild # 매핑된 페이지 전부 삭제 후 재생성
|
|
@@ -114,6 +114,8 @@ confluence-sync --exclude '*-draft.md' # 제외 패턴 (반복 가능)
|
|
|
114
114
|
- README 가 **없는** 폴더는 **Confluence 폴더**로 생성되고 그 아래로 문서가 들어갑니다 (`write:folder` 스코프 필요).
|
|
115
115
|
- `README.md` 는 폴더 맨 앞, 나머지는 파일명순(숫자 prefix `01-`, `10-` … 로 순서 제어)으로 정렬되어 부모가 자식보다 먼저 생성됩니다.
|
|
116
116
|
|
|
117
|
+
> **경로를 지정해 동기화할 때**는 상위 `README.md` 가 **발행 대상이 아닙니다.** 자식을 붙일 부모 pageId 를 얻는 데만 쓰이고, 내용이 바뀌어 있어도 갱신하지 않습니다(`· 상위` 로 표시). 아직 페이지가 없으면 자식을 붙일 곳이 없으므로 그때는 생성합니다. 상위 문서도 갱신하려면 함께 지정하세요.
|
|
118
|
+
|
|
117
119
|
```
|
|
118
120
|
docs/ → [CONFLUENCE_PARENT_ID 또는 공간 최상위]
|
|
119
121
|
├── README.md → 루트 페이지
|
|
@@ -142,6 +144,7 @@ docs/ → [CONFLUENCE_PARENT_ID 또는 공간 최상위]
|
|
|
142
144
|
| 쓰는 것 | Confluence 에서 |
|
|
143
145
|
| --- | --- |
|
|
144
146
|
| `[§1 개요](#1-개요)` | 같은 페이지의 그 헤딩으로 이동 |
|
|
147
|
+
| `[[#1. 개요]]` · `[[#1. 개요\|§1]]` | 위와 동일 (Obsidian 표기) |
|
|
145
148
|
| `[§6 ZKP](./04-구현체.md#6-zkp)` | 그 페이지의 그 헤딩으로 이동 |
|
|
146
149
|
| `[[04-구현체#6. ZKP]]` | 위와 동일 (Obsidian 표기) |
|
|
147
150
|
|
|
@@ -163,6 +166,7 @@ pull 은 반대로 Confluence 앵커를 마크다운 슬러그로 되돌립니
|
|
|
163
166
|
|
|
164
167
|
- 문서의 `제목 + 본문 + 참조 이미지 내용` 해시를 저장해두고, **바뀐 문서만** 새 버전으로 올립니다(`= 변경없음` 은 API 호출조차 하지 않음). `--force` 로 무시할 수 있습니다.
|
|
165
168
|
- 매핑은 **`<base>/.confluence-sync.json`** 에 저장됩니다. **문서셋과 함께 커밋하세요** — 지우면 다음 실행에서 페이지가 중복 생성됩니다. (`--mapping <path>` 로 위치 변경 가능)
|
|
169
|
+
- **pull 도 같은 매핑에 기록합니다** — 그래서 pull 직후 push 하면 `= 변경없음` 입니다. [Pull → 매핑 기록](#매핑-기록) 참고.
|
|
166
170
|
- Confluence 에서 페이지가 삭제됐다면 자동으로 **재생성**하고, 그 페이지를 가리키던 문서들도 **링크를 다시 연결**합니다. 내용이 그대로라 스킵되는 경우까지 잡으려면 `--verify` 를 씁니다.
|
|
167
171
|
|
|
168
172
|
### 동기화 제외 (로컬 전용 문서 유지)
|
|
@@ -209,6 +213,25 @@ confluence-sync pull --space --obsidian --out ~/MyVault # 내부 링크를 [[w
|
|
|
209
213
|
|
|
210
214
|
> **무손실 왕복은 아닙니다.** Confluence storage → Markdown 은 근사 변환이라 일부 매크로·레이아웃은 단순화됩니다.
|
|
211
215
|
|
|
216
|
+
### 매핑 기록
|
|
217
|
+
|
|
218
|
+
pull 은 받아온 문서를 **매핑 파일에 함께 기록합니다** — 페이지 id 와, push 가 계산하는 것과 **같은 방식의 변경 해시**를 남깁니다. 그래서 **pull 직후 push 하면 `= 변경없음`** 입니다. 이게 없으면 매핑에 해시가 없어 방금 받아온 내용을 그대로 되올려 보내게 됩니다.
|
|
219
|
+
|
|
220
|
+
```bash
|
|
221
|
+
$ confluence-sync pull --space --out ./docs
|
|
222
|
+
…
|
|
223
|
+
완료 페이지 42개, 폴더 3개 생성 내부링크 88개 연결 매핑 45건
|
|
224
|
+
매핑: /repo/docs/.confluence-sync.json (base: /repo/docs)
|
|
225
|
+
|
|
226
|
+
$ confluence-sync --base ./docs --dry-run
|
|
227
|
+
[동일] … # 되올려 보낼 것이 없다
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
- **base 는 `--out` 에서 위로 올라가며 `.confluence-sync.json` 을 찾아 정합니다.** 매핑 키는 base 기준 상대경로여야 이어지는 push 가 같은 문서를 알아보기 때문입니다. 하위 폴더로 받아도(`--out <base>/일부`) 루트의 매핑에 제대로 들어갑니다.
|
|
231
|
+
- 못 찾으면 `--out` 자체가 base 가 됩니다(그 디렉토리가 곧 동기화 루트가 됩니다).
|
|
232
|
+
- **`--base <dir>`** · **`--mapping <path>`** 로 직접 지정할 수 있고, **`--no-mapping`** 이면 아무것도 쓰지 않습니다.
|
|
233
|
+
- 같은 페이지가 다른 키에 남아 있으면(문서를 옮겼으면) 옛 키를 걷어내 중복 항목을 만들지 않습니다.
|
|
234
|
+
|
|
212
235
|
---
|
|
213
236
|
|
|
214
237
|
## Obsidian
|
|
@@ -273,6 +296,7 @@ confluence-sync convert --to obsidian --fix ./docs --out ~/MyVault
|
|
|
273
296
|
- 파일명이 트리 안에서 겹치면(`README.md` 여러 개 등) `[[wikilink]]` 로 바꾸지 않고 상대 링크를 유지합니다 — 이름만으로는 어느 노트인지 확정할 수 없기 때문입니다.
|
|
274
297
|
- 코드블록·인라인 코드 안의 링크는 손대지 않습니다.
|
|
275
298
|
- **섹션 링크는 표기가 다릅니다** — 표준 마크다운은 슬러그(`문서.md#6-zkp`), Obsidian 은 헤딩 텍스트(`[[문서#6. ZKP]]`)를 씁니다. 대상 문서의 헤딩을 찾아 옮기고, 못 찾으면 섹션 없이 문서 링크로 둡니다. Obsidian 블록 참조(`#^id`)는 마크다운에 대응물이 없어 같은 방식으로 처리합니다.
|
|
299
|
+
- **같은 문서 섹션**도 마찬가지입니다 — `[§4](#4-버전)` ↔ `[[#4. 버전|§4]]`. 표 안에서 `\|` 로 이스케이프된 별칭도 인식합니다.
|
|
276
300
|
|
|
277
301
|
### 보정 (`--fix`)
|
|
278
302
|
|
|
@@ -294,6 +318,8 @@ confluence-sync convert --to obsidian --fix ./docs --out ~/MyVault
|
|
|
294
318
|
| vault 에서 쓴 것 | Confluence 에 올라가는 것 |
|
|
295
319
|
| --- | --- |
|
|
296
320
|
| `[[설계]]` · `[[구조/설계\|설계 문서]]` | 페이지 링크 (짧은 이름·경로 모두 인식) |
|
|
321
|
+
| `[[설계#3. 구성]]` | 그 페이지의 그 섹션 |
|
|
322
|
+
| `[[#3. 구성]]` · `[[#3. 구성\|§3]]` | **같은 페이지**의 그 섹션 |
|
|
297
323
|
| `![[diagram.png]]` | 첨부 이미지 |
|
|
298
324
|
| frontmatter `title:` | 페이지 제목 |
|
|
299
325
|
| frontmatter `pageId:` | **그 페이지를 갱신** (새로 만들지 않음) |
|
package/dist/anchors.js
CHANGED
|
@@ -10,11 +10,33 @@
|
|
|
10
10
|
* 이름은 마크다운 슬러그를 그대로 쓰므로 왕복도 정확해진다 — 양쪽 다 우리가 정한 값이다.
|
|
11
11
|
*/
|
|
12
12
|
const FENCE = /^\s*(?:```|~~~)/;
|
|
13
|
+
/**
|
|
14
|
+
* `[[대상]]` · `[[대상|별칭]]` · `[[대상#헤딩]]` · `[[#헤딩]]` · `![[임베드]]`
|
|
15
|
+
*
|
|
16
|
+
* 대상이 **비어 있을 수 있다** — `[[#헤딩]]` 은 "이 문서의 이 섹션"이라는 Obsidian 표기다.
|
|
17
|
+
* 표 안에서는 별칭 앞 파이프가 `\|` 로 이스케이프되므로 그 백슬래시도 함께 받아 준다.
|
|
18
|
+
*/
|
|
19
|
+
export const WIKILINK_RE = /(!?)\[\[([^[\]|#]*)(#[^[\]|]+)?(?:\\?\|([^[\]]*))?\]\]/g;
|
|
20
|
+
/**
|
|
21
|
+
* WIKILINK_RE 의 캡처 그룹을 해석한다. 대상도 섹션도 없으면(`[[]]`) 링크가 아니므로 null.
|
|
22
|
+
* 표의 `\|` 때문에 앞 그룹 끝에 딸려온 백슬래시는 여기서 떼어낸다.
|
|
23
|
+
*/
|
|
24
|
+
export function parseWikilink(bang, target, hash, alias) {
|
|
25
|
+
const unesc = (s) => s.replace(/\\$/, '').trim();
|
|
26
|
+
const t = unesc(target ?? '');
|
|
27
|
+
const f = hash ? unesc(hash.slice(1)) : null;
|
|
28
|
+
if (!t && !f)
|
|
29
|
+
return null;
|
|
30
|
+
return { embed: bang === '!', target: t, fragment: f || null, alias: alias?.trim() || null };
|
|
31
|
+
}
|
|
13
32
|
/** 인라인 마크다운 표기를 벗긴다 — 슬러그는 렌더된 텍스트 기준이다. */
|
|
14
33
|
function plainText(s) {
|
|
15
34
|
return s
|
|
16
35
|
.replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1') // [label](url) → label
|
|
17
|
-
.replace(
|
|
36
|
+
.replace(WIKILINK_RE, (whole, ...g) => {
|
|
37
|
+
const w = parseWikilink(g[0], g[1], g[2], g[3]);
|
|
38
|
+
return w ? (w.alias ?? (w.target || w.fragment) ?? whole) : whole;
|
|
39
|
+
})
|
|
18
40
|
.replace(/`+/g, '')
|
|
19
41
|
.replace(/\*\*|~~|\*/g, '') // _ 는 남긴다: 슬러그가 밑줄을 보존하므로(foo_bar → foo_bar)
|
|
20
42
|
.trim();
|
|
@@ -114,7 +136,6 @@ export function wikilinkAnchorToSlug(anchor) {
|
|
|
114
136
|
return slugifyHeading(last) || null;
|
|
115
137
|
}
|
|
116
138
|
const MD_LINK = /\[([^\]]*)\]\((<[^>]+>|[^()\s]+)\)/g;
|
|
117
|
-
const WIKILINK = /(!?)\[\[([^\]|#]+)(#[^\]|]+)?(?:\|([^\]]+))?\]\]/g;
|
|
118
139
|
/**
|
|
119
140
|
* 본문에서 **앵커가 붙은 링크만** 훑는다 — 어느 문서의 어느 헤딩에 앵커를 심어야 하는지 알아내기 위해서다.
|
|
120
141
|
* 전체 렌더보다 훨씬 싸고, 코드블록·인라인 코드·이미지는 제외한다.
|
|
@@ -142,10 +163,11 @@ export function collectLinkAnchors(md) {
|
|
|
142
163
|
continue;
|
|
143
164
|
out.push({ dest: d.slice(0, i), fragment: d.slice(i + 1), wiki: false });
|
|
144
165
|
}
|
|
145
|
-
for (const m of prose.matchAll(
|
|
146
|
-
|
|
166
|
+
for (const m of prose.matchAll(WIKILINK_RE)) {
|
|
167
|
+
const w = parseWikilink(m[1], m[2], m[3], m[4]);
|
|
168
|
+
if (!w || w.embed || !w.fragment)
|
|
147
169
|
continue; // 임베드·앵커 없는 wikilink
|
|
148
|
-
out.push({ dest:
|
|
170
|
+
out.push({ dest: w.target, fragment: w.fragment, wiki: true }); // dest 가 비면 같은 문서
|
|
149
171
|
}
|
|
150
172
|
}
|
|
151
173
|
return out;
|
package/dist/convert.js
CHANGED
|
@@ -62,10 +62,19 @@ function commonAncestor(paths) {
|
|
|
62
62
|
* wikilink 는 vault 어디서든 "이름"으로 찾으므로, 파일명이 트리 안에서 유일할 때만 바꾼다
|
|
63
63
|
* (겹치면 엉뚱한 노트로 이어질 수 있어 상대 링크를 그대로 둔다).
|
|
64
64
|
*/
|
|
65
|
-
function toObsidian(text, fileAbs, baseDir, nameCount, headingOf) {
|
|
65
|
+
function toObsidian(text, fileAbs, baseDir, nameCount, headingOf, selfHeadingOf) {
|
|
66
66
|
return linksToWikilinks(text, (dest, label) => {
|
|
67
|
-
if (/^(https?:|mailto
|
|
68
|
-
return null; // 외부
|
|
67
|
+
if (/^(https?:|mailto:)/i.test(dest))
|
|
68
|
+
return null; // 외부 링크
|
|
69
|
+
// 같은 문서 섹션: [§4](#슬러그) → [[#헤딩 텍스트|§4]]
|
|
70
|
+
// Obsidian 은 슬러그가 아니라 헤딩 텍스트로 섹션을 가리키므로, 헤딩을 못 찾으면 손대지 않는다.
|
|
71
|
+
if (dest.startsWith('#')) {
|
|
72
|
+
const heading = selfHeadingOf(decode(dest.slice(1)));
|
|
73
|
+
if (!heading)
|
|
74
|
+
return null;
|
|
75
|
+
const l = label.trim();
|
|
76
|
+
return l && l !== heading ? `[[#${heading}|${l}]]` : `[[#${heading}]]`;
|
|
77
|
+
}
|
|
69
78
|
const [pathPart, ...hash] = decode(dest).split('#');
|
|
70
79
|
if (!/\.md$/i.test(pathPart))
|
|
71
80
|
return null;
|
|
@@ -207,8 +216,11 @@ export async function runConvert(argv) {
|
|
|
207
216
|
notes.push(dim(`보정 ${n}`));
|
|
208
217
|
}
|
|
209
218
|
}
|
|
210
|
-
if (to === 'obsidian')
|
|
211
|
-
|
|
219
|
+
if (to === 'obsidian') {
|
|
220
|
+
// 같은 문서 앵커는 보정(--fix)까지 끝난 **지금 본문**의 헤딩으로 풀어야 맞는다
|
|
221
|
+
const selfIdx = buildAnchorIndex(splitFrontmatter(text).body);
|
|
222
|
+
text = toObsidian(text, abs, baseDir, nameCount, headingOf, (s) => selfIdx.get(s.toLowerCase()));
|
|
223
|
+
}
|
|
212
224
|
else if (to === 'markdown')
|
|
213
225
|
text = resolveWikilinks(text, vaultResolver(rel, vault));
|
|
214
226
|
// 제자리 변환이면 바뀐 것만 쓰지만, --out 이면 대상 전부를 내보낸다(온전한 트리가 나와야 하므로)
|
package/dist/docs.js
CHANGED
|
@@ -97,7 +97,8 @@ export function resolveAnchorTargets(docs, vault) {
|
|
|
97
97
|
let target;
|
|
98
98
|
let slug;
|
|
99
99
|
if (la.wiki) {
|
|
100
|
-
|
|
100
|
+
// dest 가 비면 `[[#헤딩]]` — 같은 문서의 섹션이다
|
|
101
|
+
target = la.dest === '' ? rel : (vault.notes[la.dest.toLowerCase().replace(/\.md$/i, '')] ?? null);
|
|
101
102
|
slug = wikilinkAnchorToSlug(la.fragment);
|
|
102
103
|
}
|
|
103
104
|
else {
|
package/dist/help.js
CHANGED
|
@@ -40,15 +40,23 @@ ${h('동기화 옵션:')}
|
|
|
40
40
|
${o('-v, --version')} 버전 출력
|
|
41
41
|
|
|
42
42
|
${h('경로 인자:')}
|
|
43
|
-
파일/폴더를 주면 그 문서만
|
|
43
|
+
파일/폴더를 주면 ${bold('그 문서만')} 발행합니다.
|
|
44
|
+
상위 ${o('README.md')} 는 자식을 붙일 부모 pageId 를 얻는 데만 쓰이고 내용은 갱신되지 않습니다
|
|
45
|
+
${dim('(아직 페이지가 없으면 자식을 붙일 곳이 없으므로 그때는 생성됩니다).')}
|
|
46
|
+
상위 문서까지 갱신하려면 함께 지정하세요.
|
|
44
47
|
예) ${o('confluence-sync 20-design')} ${o('confluence-sync 90-glossary.md')}
|
|
45
48
|
|
|
46
49
|
${h('pull(역방향) 옵션:')}
|
|
47
50
|
${o('--out <dir>')} .md 를 생성할 디렉토리(기본: 현재 폴더)
|
|
48
51
|
${o('--children')} 하위 페이지까지 재귀적으로 가져와 폴더 트리로 복원
|
|
49
52
|
${o('--space')} 대상 없이 스페이스(CONFLUENCE_SPACE_KEY) 홈페이지부터 전체를 가져옴
|
|
50
|
-
${o('--obsidian')}
|
|
53
|
+
${o('--obsidian')} 내부 링크를 [[wikilink]] 로 출력(기본: 어디서나 열리는 상대 .md 링크)
|
|
54
|
+
${o('--base <dir>')} 매핑 키의 기준(기본: --out 에서 위로 올라가며 .confluence-sync.json 을 찾음)
|
|
55
|
+
${o('--mapping <path>')} 매핑 파일 위치 (기본: <base>/.confluence-sync.json)
|
|
56
|
+
${o('--no-mapping')} 매핑을 기록하지 않음
|
|
51
57
|
(이미지는 항상 attachments/<문서명>/ 하위에 저장됨)
|
|
58
|
+
받아온 문서는 pageId 와 ${dim('변경 해시')}가 매핑에 기록되어, ${bold('pull 직후 push 하면 "변경없음"')} 입니다
|
|
59
|
+
${dim('(기록하지 않으면 방금 받아온 내용을 그대로 되올려 보내게 됩니다).')}
|
|
52
60
|
예) ${o('confluence-sync pull https://.../pages/12345/Title --out ./docs --children')}
|
|
53
61
|
${o('confluence-sync pull --space --out ./docs')}
|
|
54
62
|
|
|
@@ -69,7 +77,7 @@ ${h('convert(이미 받아둔 .md 손보기) 옵션:')} ${dim('Confluence 호출
|
|
|
69
77
|
|
|
70
78
|
${h('섹션 링크(앵커):')}
|
|
71
79
|
${o('[§1 개요](#1-개요)')} 처럼 헤딩을 가리키는 링크가 Confluence 에서도 동작합니다.
|
|
72
|
-
같은 문서(${o('#슬러그')})·다른 문서(${o('문서.md#슬러그')}
|
|
80
|
+
같은 문서(${o('#슬러그')} · ${o('[[#헤딩]]')})·다른 문서(${o('문서.md#슬러그')} · ${o('[[문서#헤딩]]')}) 모두 지원합니다.
|
|
73
81
|
Confluence 의 자동 헤딩 id 는 배포판마다 달라서, 가리켜지는 헤딩 앞에 ${dim('Anchor 매크로')}로
|
|
74
82
|
이름을 직접 심고 그 이름을 가리킵니다. pull 은 반대로 슬러그를 되돌립니다.
|
|
75
83
|
대응하는 헤딩을 못 찾으면 링크를 바꾸지 않고 ${dim('⚠ 대응 헤딩 없는 앵커')} 로 알려 줍니다
|
package/dist/mapping.js
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
* 기본 위치는 <base>/.confluence-sync.json 이며 문서셋과 함께 버전관리된다.
|
|
4
4
|
*/
|
|
5
5
|
import { readFileSync, writeFileSync } from 'node:fs';
|
|
6
|
+
/** base 루트에 놓이는 매핑 파일 이름. 이 파일이 있는 곳이 곧 동기화 루트다. */
|
|
7
|
+
export const MAPPING_FILE = '.confluence-sync.json';
|
|
6
8
|
export function loadMapping(path) {
|
|
7
9
|
try {
|
|
8
10
|
return JSON.parse(readFileSync(path, 'utf8'));
|
package/dist/obsidian.js
CHANGED
|
@@ -9,10 +9,8 @@
|
|
|
9
9
|
* frontmatter 는 평면 `key: value` 만 다룬다. 중첩·리스트가 필요할 만큼 복잡한 메타데이터는
|
|
10
10
|
* 이 도구의 관심사가 아니며, 알 수 없는 키는 건드리지 않고 보존한다.
|
|
11
11
|
*/
|
|
12
|
-
import { wikilinkAnchorToSlug } from './anchors.js';
|
|
12
|
+
import { wikilinkAnchorToSlug, WIKILINK_RE, parseWikilink } from './anchors.js';
|
|
13
13
|
const FENCE = /^\s*(?:```|~~~)/;
|
|
14
|
-
// [[대상]] · [[대상|별칭]] · [[대상#헤딩]] · ![[임베드]]
|
|
15
|
-
const WIKILINK = /(!?)\[\[([^\]|#]+)(#[^\]|]+)?(?:\|([^\]]+))?\]\]/g;
|
|
16
14
|
/**
|
|
17
15
|
* 문서 맨 앞의 YAML frontmatter 를 분리한다. 없으면 data 는 빈 객체.
|
|
18
16
|
* `key: value` 한 줄짜리만 인식하고, 값의 감싼 따옴표는 벗긴다.
|
|
@@ -69,17 +67,25 @@ function mapProse(md, fn) {
|
|
|
69
67
|
* 해석에 실패한 링크는 원문 그대로 남겨 (조용히 사라지지 않고) 눈에 띄게 한다.
|
|
70
68
|
*/
|
|
71
69
|
export function resolveWikilinks(md, resolve) {
|
|
72
|
-
return mapProse(md, (s) => s.replace(
|
|
73
|
-
const
|
|
74
|
-
|
|
70
|
+
return mapProse(md, (s) => s.replace(WIKILINK_RE, (whole, bang, target, hash, alias) => {
|
|
71
|
+
const w = parseWikilink(bang, target, hash, alias);
|
|
72
|
+
if (!w)
|
|
73
|
+
return whole;
|
|
74
|
+
// Obsidian 은 섹션을 헤딩 텍스트로 가리키지만(`#1. 신뢰 — Trust Registry`)
|
|
75
|
+
// 표준 마크다운은 슬러그를 쓴다. 블록 참조(`#^id`)는 대응물이 없어 섹션을 떼어 낸다.
|
|
76
|
+
const slug = w.fragment ? wikilinkAnchorToSlug(w.fragment) : null;
|
|
77
|
+
// [[#헤딩]] — 대상이 없으면 "이 문서의 이 섹션"이다. vault 조회 없이 앵커 링크가 된다.
|
|
78
|
+
if (!w.target) {
|
|
79
|
+
if (!slug)
|
|
80
|
+
return whole; // 블록 참조뿐 → 갈 곳이 없으므로 원문 보존
|
|
81
|
+
return `[${w.alias ?? w.fragment}](#${slug})`;
|
|
82
|
+
}
|
|
83
|
+
const found = resolve(w.target, w.embed);
|
|
75
84
|
if (!found)
|
|
76
85
|
return whole; // 해석 실패 → 원문 보존
|
|
77
|
-
const label =
|
|
78
|
-
if (embed)
|
|
86
|
+
const label = w.alias ?? w.target;
|
|
87
|
+
if (w.embed)
|
|
79
88
|
return `})`;
|
|
80
|
-
// Obsidian 은 섹션을 헤딩 텍스트로 가리키지만(`#1. 신뢰 — Trust Registry`)
|
|
81
|
-
// 표준 마크다운은 슬러그를 쓴다. 블록 참조(`#^id`)는 대응물이 없어 링크만 남긴다.
|
|
82
|
-
const slug = hash ? wikilinkAnchorToSlug(hash.slice(1)) : null;
|
|
83
89
|
return `[${label}](${dest(found + (slug ? `#${slug}` : ''))})`;
|
|
84
90
|
}));
|
|
85
91
|
}
|
package/dist/pull.js
CHANGED
|
@@ -7,13 +7,18 @@
|
|
|
7
7
|
* 함께 받은 페이지끼리의 내부 링크는 마지막에 상대 .md 경로로 이어 붙인다(Obsidian 그래프·백링크가 살아난다).
|
|
8
8
|
* 각 문서 머리에 pageId 를 담은 frontmatter 를 남겨, 매핑 파일 없이도 push 가 원본 페이지를 다시 찾아간다.
|
|
9
9
|
*/
|
|
10
|
-
import { mkdirSync, writeFileSync, readFileSync } from 'node:fs';
|
|
10
|
+
import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
|
|
11
11
|
import { resolve, join, dirname, basename, relative } from 'node:path';
|
|
12
12
|
import { readEnv, requireEnv } from './config.js';
|
|
13
13
|
import { createClient } from './confluence.js';
|
|
14
14
|
import { htmlToMarkdown, codeLanguagesFromStorage } from './html2md.js';
|
|
15
15
|
import { buildFrontmatter, splitFrontmatter } from './obsidian.js';
|
|
16
16
|
import { collectHeadings, matchConfluenceAnchor } from './anchors.js';
|
|
17
|
+
import { collectMarkdown } from './docs.js';
|
|
18
|
+
import { buildTreeRenderer } from './render.js';
|
|
19
|
+
import { docHash } from './markdown.js';
|
|
20
|
+
import { buildIgnorer } from './ignore.js';
|
|
21
|
+
import { loadMapping, saveMapping, MAPPING_FILE } from './mapping.js';
|
|
17
22
|
import { cyan, dim, green, red, yellow } from './colors.js';
|
|
18
23
|
// 첨부 이미지는 항상 <md위치>/attachments/<문서명>/ 하위에 저장한다.
|
|
19
24
|
const ASSETS_DIR = 'attachments';
|
|
@@ -112,6 +117,7 @@ async function pullNode(ctx, id, destDir) {
|
|
|
112
117
|
if (node.type === 'folder') {
|
|
113
118
|
const dir = join(destDir, slug(node.title));
|
|
114
119
|
mkdirSync(dir, { recursive: true });
|
|
120
|
+
ctx.folderIds.set(dir, id);
|
|
115
121
|
console.log(` ${cyan('📁 폴더')} ${dir} ${dim(`(#${id})`)}`);
|
|
116
122
|
const counts = { pages: 0, folders: 1 };
|
|
117
123
|
if (withChildren) {
|
|
@@ -201,7 +207,7 @@ export function relinkPass(written, pathById, wikilinks, info) {
|
|
|
201
207
|
let count = 0;
|
|
202
208
|
for (const file of written) {
|
|
203
209
|
const before = readFileSync(file, 'utf8');
|
|
204
|
-
|
|
210
|
+
let after = before.replace(PAGE_LINK, (whole, text, url, id) => {
|
|
205
211
|
const target = pathById.get(id);
|
|
206
212
|
if (!target || target === file)
|
|
207
213
|
return whole; // 못 받은 페이지·자기 자신 → 원본 URL 유지
|
|
@@ -219,15 +225,89 @@ export function relinkPass(written, pathById, wikilinks, info) {
|
|
|
219
225
|
const rel = relative(dirname(file), target).split('\\').join('/');
|
|
220
226
|
return `[${label}](${dest(rel + (slug ? `#${slug}` : ''))})`;
|
|
221
227
|
});
|
|
228
|
+
// 같은 문서 섹션도 vault 표기로: [§4](#슬러그) → [[#헤딩 텍스트|§4]]
|
|
229
|
+
if (wikilinks) {
|
|
230
|
+
after = after.replace(SELF_ANCHOR, (whole, text, frag) => {
|
|
231
|
+
const heading = info?.get(file)?.textBySlug.get(frag);
|
|
232
|
+
if (!heading)
|
|
233
|
+
return whole;
|
|
234
|
+
count++;
|
|
235
|
+
const label = text.trim();
|
|
236
|
+
return label && label !== heading ? `[[#${heading}|${label}]]` : `[[#${heading}]]`;
|
|
237
|
+
});
|
|
238
|
+
}
|
|
222
239
|
if (after !== before)
|
|
223
240
|
writeFileSync(file, after);
|
|
224
241
|
}
|
|
225
242
|
return count;
|
|
226
243
|
}
|
|
244
|
+
/**
|
|
245
|
+
* 매핑 파일(.confluence-sync.json)이 있는 가장 가까운 상위 디렉토리 = 동기화 루트.
|
|
246
|
+
* pull 을 base 하위 폴더로 받아도(`--out <base>/어딘가`) 매핑 키가 base 기준으로 남아야
|
|
247
|
+
* 이어지는 push 가 같은 문서를 알아본다.
|
|
248
|
+
*/
|
|
249
|
+
function findSyncBase(from) {
|
|
250
|
+
for (let d = from;; d = dirname(d)) {
|
|
251
|
+
if (existsSync(join(d, MAPPING_FILE)))
|
|
252
|
+
return d;
|
|
253
|
+
if (dirname(d) === d)
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* 받아온 문서를 매핑 파일에 기록한다.
|
|
259
|
+
*
|
|
260
|
+
* 이게 없으면 pull 직후의 push 가 모든 문서를 "변경"으로 본다 — 매핑에 해시가 없으니
|
|
261
|
+
* 방금 받아온 내용을 그대로 되올려 보내려 한다. 해시는 push 가 쓰는 것과 **같은 렌더러**로
|
|
262
|
+
* 계산해야 하므로(buildTreeRenderer) 트리 전체를 한 번 훑는다.
|
|
263
|
+
*
|
|
264
|
+
* 반환값은 기록한 항목 수.
|
|
265
|
+
*/
|
|
266
|
+
export function mappingPass(baseDir, mappingPath, pathById, folderIds) {
|
|
267
|
+
const posix = (p) => p.split('\\').join('/');
|
|
268
|
+
const ignorer = buildIgnorer(baseDir, []);
|
|
269
|
+
const rels = collectMarkdown(baseDir)
|
|
270
|
+
.map((f) => posix(relative(baseDir, f)))
|
|
271
|
+
.filter((r) => !ignorer.ignores(r));
|
|
272
|
+
const { docs, render } = buildTreeRenderer(baseDir, rels);
|
|
273
|
+
const m = loadMapping(mappingPath);
|
|
274
|
+
// 같은 페이지가 다른 키에 남아 있으면(문서가 옮겨졌으면) 중복 항목이 된다 → 옛 키를 걷어낸다
|
|
275
|
+
const ids = new Set([...pathById.keys(), ...folderIds.values()]);
|
|
276
|
+
for (const [k, v] of Object.entries(m))
|
|
277
|
+
if (ids.has(v.pageId))
|
|
278
|
+
delete m[k];
|
|
279
|
+
let n = 0;
|
|
280
|
+
for (const [id, abs] of pathById) {
|
|
281
|
+
const rel = posix(relative(baseDir, abs));
|
|
282
|
+
const doc = rel.startsWith('..') ? undefined : docs[rel];
|
|
283
|
+
if (!doc)
|
|
284
|
+
continue; // base 밖으로 받았거나 제외 규칙에 걸린 문서는 기록하지 않는다
|
|
285
|
+
m[rel] = { pageId: id, title: doc.title, hash: docHash(doc.title, render(rel, doc.body, doc.title)) };
|
|
286
|
+
n++;
|
|
287
|
+
}
|
|
288
|
+
for (const [dir, id] of folderIds) {
|
|
289
|
+
const rel = posix(relative(baseDir, dir));
|
|
290
|
+
if (!rel || rel.startsWith('..'))
|
|
291
|
+
continue;
|
|
292
|
+
m[`${rel}/`] = { pageId: id, title: basename(dir), type: 'folder' };
|
|
293
|
+
n++;
|
|
294
|
+
}
|
|
295
|
+
saveMapping(mappingPath, m);
|
|
296
|
+
return n;
|
|
297
|
+
}
|
|
227
298
|
export async function runPull(argv) {
|
|
228
299
|
const withChildren = argv.includes('--children');
|
|
229
300
|
const wholeSpace = argv.includes('--space');
|
|
230
301
|
const outDir = resolve(optVal(argv, '--out') ?? process.cwd());
|
|
302
|
+
// 매핑을 어디에 남길지. base 는 매핑 키의 기준이므로 push 때의 --base 와 같아야 한다.
|
|
303
|
+
const noMapping = argv.includes('--no-mapping');
|
|
304
|
+
const baseOpt = optVal(argv, '--base');
|
|
305
|
+
const baseDir = resolve(baseOpt ?? findSyncBase(outDir) ?? outDir);
|
|
306
|
+
const mappingPath = resolve(optVal(argv, '--mapping') ?? join(baseDir, MAPPING_FILE));
|
|
307
|
+
if (!noMapping && relative(baseDir, outDir).startsWith('..')) {
|
|
308
|
+
console.error(red(`✗ --out 이 base 밖입니다: ${outDir}`) + dim(`\n base: ${baseDir} (--base 로 맞추거나 --no-mapping 을 쓰세요)`));
|
|
309
|
+
process.exit(1);
|
|
310
|
+
}
|
|
231
311
|
const env = readEnv();
|
|
232
312
|
requireEnv(env);
|
|
233
313
|
const client = createClient({ baseUrl: env.baseUrl, email: env.email, token: env.token }, { force: false, verify: false });
|
|
@@ -238,14 +318,20 @@ export async function runPull(argv) {
|
|
|
238
318
|
spaceKey: env.spaceKey,
|
|
239
319
|
written: [],
|
|
240
320
|
pathById: new Map(),
|
|
321
|
+
folderIds: new Map(),
|
|
241
322
|
};
|
|
242
323
|
const done = (c) => {
|
|
243
324
|
// 앵커를 먼저 슬러그로 바꾼 뒤 링크를 상대경로로 옮긴다(순서가 바뀌면 어느 페이지의 헤딩인지 알 수 없다).
|
|
244
325
|
const { count: anchors, info } = anchorPass(ctx.written, ctx.pathById);
|
|
245
326
|
const relinked = relinkPass(ctx.written, ctx.pathById, ctx.obsidian, info);
|
|
327
|
+
// 본문이 다 정해진 뒤에 기록해야 해시가 맞는다
|
|
328
|
+
const mapped = noMapping ? 0 : mappingPass(baseDir, mappingPath, ctx.pathById, ctx.folderIds);
|
|
246
329
|
console.log(`\n${green('완료')} 페이지 ${c.pages}개${c.folders ? `, 폴더 ${c.folders}개` : ''} 생성` +
|
|
247
330
|
(relinked ? ` ${cyan(`내부링크 ${relinked}개 연결`)}` : '') +
|
|
248
|
-
(anchors ? ` ${cyan(`섹션링크 ${anchors}개 복원`)}` : '')
|
|
331
|
+
(anchors ? ` ${cyan(`섹션링크 ${anchors}개 복원`)}` : '') +
|
|
332
|
+
(mapped ? ` ${cyan(`매핑 ${mapped}건`)}` : ''));
|
|
333
|
+
if (mapped)
|
|
334
|
+
console.log(dim(` 매핑: ${mappingPath} (base: ${baseDir})`));
|
|
249
335
|
};
|
|
250
336
|
// --space: 스페이스 홈페이지(콘텐츠 트리 루트)부터 전체를 재귀적으로 가져옴
|
|
251
337
|
if (wholeSpace) {
|
|
@@ -262,7 +348,7 @@ export async function runPull(argv) {
|
|
|
262
348
|
// 위치 인자: ['pull', <pageId|url>, ...]
|
|
263
349
|
const positionals = [];
|
|
264
350
|
for (let i = 0; i < argv.length; i++) {
|
|
265
|
-
if (argv[i] === '--out') {
|
|
351
|
+
if (argv[i] === '--out' || argv[i] === '--base' || argv[i] === '--mapping') {
|
|
266
352
|
i++;
|
|
267
353
|
continue;
|
|
268
354
|
}
|
package/dist/render.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 문서 트리 하나를 storage format 으로 옮기는 렌더러.
|
|
3
|
+
*
|
|
4
|
+
* 링크·섹션 링크는 **트리 전체**를 알아야 풀린다(다른 문서의 제목과 헤딩을 참조하므로),
|
|
5
|
+
* 그래서 문서 하나가 아니라 트리 단위로 만든다.
|
|
6
|
+
*
|
|
7
|
+
* push(sync)와 pull 의 매핑 기록이 **같은 문서에 같은 해시**를 내야 하므로 —
|
|
8
|
+
* 어긋나면 pull 직후 push 가 전부 "변경"으로 보인다 — 두 곳이 이 함수 하나를 함께 쓴다.
|
|
9
|
+
*/
|
|
10
|
+
import { relative } from 'node:path';
|
|
11
|
+
import { toStorage } from './markdown.js';
|
|
12
|
+
import { buildDocIndex, buildVault, collectAssets, vaultResolver, resolveAnchorTargets, } from './docs.js';
|
|
13
|
+
export function buildTreeRenderer(baseDir, rels) {
|
|
14
|
+
const { titles, anchors, docs } = buildDocIndex(rels, baseDir);
|
|
15
|
+
// Obsidian [[wikilink]] 해석용 이름 인덱스
|
|
16
|
+
const vault = buildVault(rels, collectAssets(baseDir).map((f) => relative(baseDir, f)));
|
|
17
|
+
// 누가 어느 헤딩을 가리키는지 먼저 모은다 — 그 헤딩에만 Anchor 매크로를 심는다.
|
|
18
|
+
const anchorTargets = resolveAnchorTargets(docs, vault);
|
|
19
|
+
const render = (rel, body, title) => toStorage(body, rel, titles, baseDir, {
|
|
20
|
+
resolveLink: vaultResolver(rel, vault),
|
|
21
|
+
anchorIndex: anchors,
|
|
22
|
+
title,
|
|
23
|
+
anchorNames: anchorTargets[rel],
|
|
24
|
+
bodySlugs: docs[rel]?.bodySlugs,
|
|
25
|
+
});
|
|
26
|
+
return { titles, docs, render };
|
|
27
|
+
}
|
package/dist/sync.js
CHANGED
|
@@ -17,8 +17,9 @@ import 'dotenv/config';
|
|
|
17
17
|
import { existsSync, statSync } from 'node:fs';
|
|
18
18
|
import { resolve, relative } from 'node:path';
|
|
19
19
|
import { readEnv, requireEnv } from './config.js';
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
20
|
+
import { docHash } from './markdown.js';
|
|
21
|
+
import { buildTreeRenderer } from './render.js';
|
|
22
|
+
import { collectMarkdown, buildFolderIndex, readDoc, parentKeyOf, containerKeyOf, sortForSync, resolveSelection, withParents, } from './docs.js';
|
|
22
23
|
import { loadMapping, saveMapping } from './mapping.js';
|
|
23
24
|
import { createClient } from './confluence.js';
|
|
24
25
|
import { runInit } from './init.js';
|
|
@@ -108,25 +109,21 @@ async function main() {
|
|
|
108
109
|
const allRel = collected.filter((r) => !ignorer.ignores(r)); // 제외된 문서는 모든 단계에서 빠짐
|
|
109
110
|
const ignoredCount = collected.length - allRel.length;
|
|
110
111
|
const folderIndex = buildFolderIndex(allRel);
|
|
111
|
-
// 링크·섹션 링크 변환은 항상 전체
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
const
|
|
119
|
-
resolveLink: vaultResolver(rel, vault),
|
|
120
|
-
anchorIndex,
|
|
121
|
-
title,
|
|
122
|
-
anchorNames: anchorTargets[rel],
|
|
123
|
-
bodySlugs: docs[rel]?.bodySlugs,
|
|
124
|
-
});
|
|
125
|
-
const selected = positionals.length
|
|
126
|
-
? withParents(resolveSelection(allRel, positionals, BASE_DIR), folderIndex)
|
|
127
|
-
: allRel;
|
|
112
|
+
// 링크·섹션 링크 변환은 항상 트리 전체 기준이다. 선택 동기화여도 대상 문서의 제목·헤딩을 알아야 하고,
|
|
113
|
+
// 다른 문서가 이 문서의 섹션을 가리킬 수 있다. 제외된 문서는 링크 대상에서도 빠진다.
|
|
114
|
+
const { titles: titleIndex, render } = buildTreeRenderer(BASE_DIR, allRel);
|
|
115
|
+
// 사용자가 실제로 고른 문서. 경로 인자가 없으면 전체가 대상이다.
|
|
116
|
+
const picked = positionals.length ? resolveSelection(allRel, positionals, BASE_DIR) : allRel;
|
|
117
|
+
const explicit = new Set(picked);
|
|
118
|
+
// 상위 README 를 함께 넣는 것은 **자식의 부모 pageId 를 얻기 위해서**다(발행 대상이 아니다).
|
|
119
|
+
const selected = positionals.length ? withParents(picked, folderIndex) : allRel;
|
|
128
120
|
const files = sortForSync(selected);
|
|
129
|
-
|
|
121
|
+
/** 딸려온 상위 README 인가 — 이미 페이지가 있으면 내용은 건드리지 않는다. */
|
|
122
|
+
const isCarriedParent = (rel) => !explicit.has(rel);
|
|
123
|
+
const scope = positionals.length
|
|
124
|
+
? `선택 ${explicit.size}/${allRel.length}건` +
|
|
125
|
+
(files.length > explicit.size ? dim(` (+상위 ${files.length - explicit.size})`) : '')
|
|
126
|
+
: `${files.length}건`;
|
|
130
127
|
const ignoredNote = ignoredCount ? dim(` (제외 ${ignoredCount})`) : '';
|
|
131
128
|
console.log(`${dim('base:')} ${cyan(BASE_DIR)}\n${dim('대상:')} ${scope}${ignoredNote}`);
|
|
132
129
|
// ---- 경로 헬퍼 ----
|
|
@@ -167,6 +164,12 @@ async function main() {
|
|
|
167
164
|
const fex = mapping[`${d}/`];
|
|
168
165
|
console.log(` [${fex?.pageId ? gray('폴더') : green('폴더+')}] 📁 ${d}/`);
|
|
169
166
|
}
|
|
167
|
+
const ex0 = mapping[rel];
|
|
168
|
+
// 딸려온 상위 README 는 pageId 만 쓰고 지나간다 — 고르지 않은 문서를 발행하지 않는다
|
|
169
|
+
if (isCarriedParent(rel) && ex0?.pageId) {
|
|
170
|
+
console.log(` [${gray('상위')}] ${rel} ${dim(`(pageId #${ex0.pageId} 만 사용)`)}`);
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
170
173
|
const { title, body, fm } = readDoc(BASE_DIR, rel);
|
|
171
174
|
const r = render(rel, body, title);
|
|
172
175
|
const hash = docHash(title, r);
|
|
@@ -193,7 +196,7 @@ async function main() {
|
|
|
193
196
|
saveMapping(MAPPING_PATH, {});
|
|
194
197
|
}
|
|
195
198
|
const work = REBUILD ? {} : mapping;
|
|
196
|
-
let created = 0, updated = 0, skipped = 0, recreated = 0, relinked = 0, foldersMade = 0, linked = 0;
|
|
199
|
+
let created = 0, updated = 0, skipped = 0, recreated = 0, relinked = 0, foldersMade = 0, linked = 0, carried = 0;
|
|
197
200
|
const rendered = new Map();
|
|
198
201
|
const recreatedTitles = new Set(); // 이번에 재생성된 문서의 제목
|
|
199
202
|
const touched = new Set(); // 이번에 발행(생성/갱신/재생성)된 문서
|
|
@@ -243,6 +246,14 @@ async function main() {
|
|
|
243
246
|
console.error(yellow(` ✗ 건너뜀 ${rel} (상위 폴더 미생성)`));
|
|
244
247
|
continue;
|
|
245
248
|
}
|
|
249
|
+
// 딸려온 상위 README 는 자식의 부모 pageId 를 얻으려고 들어온 것이다.
|
|
250
|
+
// 이미 페이지가 있으면 내용은 건드리지 않는다 — `csync <파일>` 이 고르지 않은 문서를 발행하면 안 된다.
|
|
251
|
+
// (페이지가 아직 없으면 자식을 붙일 곳이 없으므로 아래에서 생성한다)
|
|
252
|
+
if (isCarriedParent(rel) && work[rel]?.pageId) {
|
|
253
|
+
console.log(` ${gray('· 상위')} ${rel} ${dim(`(pageId #${work[rel].pageId} 만 사용)`)}`);
|
|
254
|
+
carried++;
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
246
257
|
const { title, body, fm } = readDoc(BASE_DIR, rel);
|
|
247
258
|
const r = render(rel, body, title);
|
|
248
259
|
const hash = docHash(title, r);
|
|
@@ -332,6 +343,7 @@ async function main() {
|
|
|
332
343
|
` ${gray(`변경없음 ${skipped}`)}` +
|
|
333
344
|
(linked ? ` ${cyan(`연결 ${linked}`)}` : '') +
|
|
334
345
|
(foldersMade ? ` ${cyan(`폴더 ${foldersMade}`)}` : '') +
|
|
335
|
-
` ${
|
|
346
|
+
(carried ? ` ${gray(`상위 ${carried}`)}` : '') +
|
|
347
|
+
` ${dim(`대상 ${files.length - carried}`)}`);
|
|
336
348
|
}
|
|
337
349
|
main().catch((e) => { console.error(e); process.exit(1); });
|
package/package.json
CHANGED